iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Software Development

Kotlin Ktor 實戰 101系列 第 2

Kotlin Ktor 實戰 101 Day 02 用 Gradle 建立 Ktor 專案

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

上一篇把系列的路線圖交代完了,這篇開始動手,把貫穿整個系列的 todo-api 專案建起來,確認 build、run、test 都能跑

Ktor 官方有提供 start.ktor.io 產生器,勾一勾 plugin 就能下載一個完整專案,趕時間的話直接用它沒問題,不過這個系列不走這條路,理由是產生器塞進來的東西不少,plugin、設定檔、範例程式一次到位,反而分不出哪些是必要的,這個系列想讓每個相依都是自己加進來的,加的時候就知道它為什麼在那裡,所以我們手寫一個最小的 Gradle 專案,2 個 Gradle 設定檔、一個 main、一個測試,總共就這些

這篇要完成什麼

  • 手寫一個最小的 Gradle 專案,用官方 version catalog Ktor 3.5.2
  • embeddedServer 跑起來,用 curl 打到第 1 個回應
  • 補一個環境測試,確認 Ktor 相依真的載得到
  • git 初始化與 .gitignore

上一個系列 Relix day 02 做的是同一件事,那時用的是 Kotlin Toolchain CLI,這次換成 Gradle,原因是 2 個系列要的東西不一樣

Relix 從頭到尾只靠 JDK 內建的類別,一個外部相依都沒有,module.yaml 寫一行 product: jvm/app 就夠用,建置設定越少越好,注意力才留得住,這個系列剛好相反,Ktor 本身就是一組模組拼起來的,後面還會接上 Exposed、Flyway、Testcontainers,day 29 的 OpenAPI 文件產生器更是以 Gradle plugin 的形式提供,沒有 Gradle 就用不了,加上 Ktor 官方文件、範例、第三方套件的說明都以 Gradle 為主,查資料時比較好對照,所以這次就走這條路

確認 JDK 與 Gradle 版本

先確認本機的 JDK

java --version

我實測時的版本是

openjdk 21.0.11 2026-04-21 LTS
OpenJDK Runtime Environment Temurin-21.0.11+10 (build 21.0.11+10-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.11+10 (build 21.0.11+10-LTS, mixed mode, sharing)

再確認 Gradle

gradle --version
Gradle 9.7.1

全域的 gradle 指令只會在建專案時用到,等一下產生 wrapper 之後,就一律改用專案內的 ./gradlew

建立專案

先建目錄,package 用 com.cashwu.todo

mkdir todo-api
cd todo-api
mkdir -p src/main/kotlin/com/cashwu/todo
mkdir -p src/test/kotlin/com/cashwu/todo

settings.gradle.kts 與 version catalog

在專案根目錄的 settings.gradle.kts 加上

rootProject.name = "todo-api"

dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        create("ktorLibs") {
            from("io.ktor:ktor-version-catalog:3.5.2")
        }
    }
}

重點是 versionCatalogs 那一段,Ktor 官方發佈了一個 version catalog,coordinates 是 io.ktor:ktor-version-catalog:3.5.2,引入之後所有 Ktor 模組的版本就一起鎖在 3.5.2,之後每加一個 Ktor 相依都不用再寫版本號,也不會發生模組之間版本不一致的問題,這就是 day 01 說的「版本統一放在一個檔案裡」,要升版時只改這一行

另一個要注意的地方是 repositories 放在 dependencyResolutionManagement 裡,而不是照舊習慣寫在 build.gradle.kts,這不是風格問題,放錯地方 catalog 會直接解析失敗,原因後面的陷阱段落會講

build.gradle.kts

在專案根目錄的 build.gradle.kts 加上

import org.gradle.api.tasks.testing.logging.TestExceptionFormat

plugins {
    kotlin("jvm") version "2.2.20"
    application
}

application {
    mainClass.set("com.cashwu.todo.ApplicationKt")
}

dependencies {
    implementation(ktorLibs.server.core)
    implementation(ktorLibs.server.netty)
    implementation("ch.qos.logback:logback-classic:1.6.3")

    testImplementation(kotlin("test"))
}

tasks.test {
    useJUnitPlatform()
    testLogging {
        events("passed", "failed")
        exceptionFormat = TestExceptionFormat.FULL
        showStackTraces = false
    }
}

幾個地方說明一下

  • Kotlin 2.2.20,這個版本是刻意選的,理由放在後面的取捨段落
  • ktorLibs.server.corektorLibs.server.netty,這就是 version catalog 的 accessor 寫法,命名規則是把 artifact 名去掉 ktor- 前綴,dash 換成一層層的點,所以 ktor-server-core 變成 ktorLibs.server.core,之後系列裡每加一個新模組都照同一個規則,server.core 是 Ktor server 的核心 API,server.netty 是實際監聽 port 的 engine,兩者的分工 day 04 會拆開來講
  • logback,Ktor server 的 log 走 SLF4J 介面,SLF4J 只是介面,要有一個實作它才會真的輸出,logback 就是那個實作,它不是 Ktor 模組,不在 catalog 裡,所以版本自己寫,這裡用當下最新的 1.6.3,1.5.18 有 CVE-2025-11226(1.5.19 修掉)、1.5.24 以前有 CVE-2026-1225(1.5.25 修掉),2 個都是設定檔被竄改就能執行任意類別的問題,版本一有問題 IDE 就會標紅底線提醒你,少了 logback 程式照樣能跑,但你會看不到任何啟動訊息,這也是後面陷阱段落的其中一個
  • mainClass,指向 ApplicationKt,這是 Kotlin 的慣例,Application.kt 這個檔案裡的 top-level main 函式,編譯後會放在名為 ApplicationKt 的類別裡
  • testLogging 那 3 行,Gradle 預設只告訴你「有測試失敗,去看 HTML 報告」,命令列上什麼細節都沒有,events("passed", "failed") 讓每個測試各印一行通過或失敗,exceptionFormat 預設是 SHORT,失敗只會印例外的類別名加一行位置,看不到「期望什麼、實際拿到什麼」,換成 TestExceptionFormat.FULL 才會帶上那句訊息,代價是它連整串 stack trace 一起印,測試跑在 coroutine 上,一個失敗就是 10 幾行雜訊,所以再加 showStackTraces = false,留訊息、不要 stack trace,這個系列後面每次「跑測試看失敗訊息」都是靠這 3 行,TestExceptionFormat 要在檔案最上面 import

第一個進入點

src/main/kotlin/com/cashwu/todo/Application.kt 加上

package com.cashwu.todo

import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing

fun main() {
    embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}

fun Application.module() {
    routing {
        get("/") {
            call.respondText("Hello, Ktor!")
        }
    }
}

embeddedServer 用 Netty 當 engine,在 8080 開一個 server,module 裡註冊一個回應純文字的路由,這裡先把它當成固定寫法就好,Application 和 engine 的關係、module 為什麼長這樣,是 day 04 的主題,routing 這個 DSL 則是 day 05 的主題,這篇不展開

環境測試

src/test/kotlin/com/cashwu/todo/EnvironmentTest.kt 加上

package com.cashwu.todo

import kotlin.test.Test
import kotlin.test.assertTrue

class EnvironmentTest {
    @Test
    fun `Ktor EmbeddedServer class is available`() {
        val clazz = Class.forName("io.ktor.server.engine.EmbeddedServer")

        assertTrue(clazz.methods.isNotEmpty())
    }
}

這個測試跟 Relix day 02 補的 HttpServer 環境測試是同一個用意,不是在測 Ktor,而是確認相依真的載得到,version catalog 有解析成功、server.core 有進到 classpath,如果 catalog 設定有問題,這裡會先失敗,總比下一篇寫測試慣例時才發現好

你可能會問,為什麼不直接用 testApplication 發一個請求來測 ? 因為 testApplication 是 day 03 的主題,這篇刻意只用 kotlin.test,讓專案骨架保持最小

產生 Gradle wrapper

最後一步,在專案根目錄產生 wrapper

gradle wrapper --gradle-version 9.7.1

之後所有指令一律用 ./gradlew,不用全域的 gradle,概念跟 Relix day 02 講過的專案內 ./kotlin wrapper 一樣,專案自己帶著固定入口,新成員 clone 下來不用先對齊本機工具版本

專案結構

到這裡,專案長這樣

todo-api/
├── settings.gradle.kts
├── build.gradle.kts
├── gradlew
├── gradlew.bat
├── gradle/
│   └── wrapper/
└── src/
    ├── main/kotlin/com/cashwu/todo/Application.kt
    └── test/kotlin/com/cashwu/todo/EnvironmentTest.kt

跟 start.ktor.io 產出的專案相比少了很多東西,沒有 application.yaml、沒有一排預裝的 plugin,這是刻意的,這些東西後面每一篇會在需要的時候自己加進來

確認 build 與測試

跑 build,這個 task 會連測試一起執行

./gradlew build

我實測的結果,尾段是

> Task :test

EnvironmentTest > Ktor EmbeddedServer class is available() PASSED

BUILD SUCCESSFUL in 5s
7 actionable tasks: 7 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html

一個測試通過,表示 version catalog 解析成功,Ktor 的類別也真的在 classpath 上

跑起來

./gradlew run

啟動 log 是

...
15:10:18.374 [main] DEBUG io.netty.buffer.ByteBufUtil -- -Dio.netty.allocator.type: adaptive
15:10:18.374 [main] DEBUG io.netty.buffer.ByteBufUtil -- -Dio.netty.threadLocalDirectBufferSize: 0
15:10:18.374 [main] DEBUG io.netty.buffer.ByteBufUtil -- -Dio.netty.maxThreadLocalCharBufferSize: 16384
15:10:18.374 [main] DEBUG io.netty.bootstrap.ChannelInitializerExtensions -- -Dio.netty.bootstrap.extensions: null
15:10:18.381 [DefaultDispatcher-worker-1] INFO io.ktor.server.Application -- Responding at http://0.0.0.0:8080

能看到這 3 行是 logback 的功勞,沒有它的話這裡會是一片空白。因為 start(wait = true) 會讓 server 一直跑著,這個指令不會自己結束,另開一個視窗打請求

curl http://localhost:8080/
Hello, Ktor!

第 1 個回應到手,確認完之後回原視窗按 Ctrl+C 停掉 server

Git 初始化與 .gitignore

如果你要跟著系列一路做,建議現在就初始化 git

git init

在專案根目錄的 .gitignore 至少放這些

.gradle/
build/
.idea/
.kotlin/
*.iml
.DS_Store

.gradle/build/ 是 Gradle 的快取和產出物,.kotlin/ 是 Kotlin 編譯器的快取,這 3 個都是機器自己長出來的,不該進版控,要注意 gradle/wrapper/gradlew 是要提交的,wrapper 進了版控,別人 clone 下來才有固定入口可以用

常見陷阱與設計取捨

  • repositories 放錯地方,catalog 解析失敗

照舊習慣把 repositories 寫在 build.gradle.kts,build 會直接失敗

* What went wrong:
Could not resolve all artifacts for configuration 'incomingCatalogForKtorLibs0'.
> Cannot resolve external dependency io.ktor:ktor-version-catalog:3.5.2 because no repositories are defined.

原因是 version catalog 在 settings 階段就要解析,那個時間點 build.gradle.kts 還沒被讀到,裡面定義的 repositories 自然不存在,解法就是像前面那樣,把 repositories 定義在 settings.gradle.ktsdependencyResolutionManagement 裡,這樣一般相依也會共用同一份定義,build.gradle.kts 不用再寫一次

  • IDE 說 repositories 是 unstable,不用理它

照上面那樣寫完,IntelliJ 會在 dependencyResolutionManagement 裡的 repositories 底下畫一條波浪線

'repositories(org.gradle.api.Action<? super org.gradle.api.artifacts.dsl.RepositoryHandler>)'
is marked unstable with @Incubating

@Incubating 是 Gradle 標記「這個 API 之後可能會改」用的,到 Gradle 9.7.1 這個方法還掛著它,看到 unstable 難免會想是不是寫錯了,但這裡沒有別條路可以走,上一個陷阱講過 catalog 在 settings 階段就要解析,repositories 不放這裡就解析不到,所以這個警告是 IDE 的靜態檢查,./gradlew build 不會有任何抱怨,功能也不受影響,看到就當沒看到

真的不想看到那條線的話,在 settings.gradle.kts 第 1 行加上這個就會安靜

@file:Suppress("UnstableApiUsage")
  • 忘了加 logback

程式照跑,路由也能回應,但啟動時只有幾行 SLF4J 找不到 provider 的警告,看不到 Application started,也看不到之後任何 log。功能正常但沒有 log 的服務,除錯時很吃虧,所以這篇一開始就把它放進相依

  • Kotlin 版本為什麼是 2.2.20

day 29 會用到的 OpenAPI Gradle extension 明確要求 Kotlin 2.2.20,用別的版本可能會編譯失敗,一開始就對齊,後面就不用中途改版本,這裡刻意不追最新版,就是為了那個 extension,這也是自己手寫專案的好處,每個版本都是有意識選的

  • ./gradlew,不要用全域 gradle

理由跟 Relix 系列講 ./kotlin 時一樣,讓每個人走專案自己的入口,不會因為本機版本不同產生奇怪差異。wrapper 產生之後,全域的 gradle 就功成身退了


小結

這篇把 todo-api 的骨架準備好了,2 個 Gradle 設定檔加上 wrapper,用官方 version catalog 把所有 Ktor 模組鎖在 3.5.2,一個最小的 embeddedServer 能跑、能回應 curl,一個環境測試確認相依載得到,git 也初始化了

如果你覺得這樣一個一個手寫太麻煩,還有一條比較省事的路,先用 start.ktor.io 產一個專案下來,再把用不到的東西刪掉,對照前面那段專案結構,留下 settings.gradle.ktsbuild.gradle.kts 跟 wrapper,加上 Application.kt 和測試就夠了,產生器多給的、預裝的 plugin 跟範例程式都可以先拿掉,後面需要哪一個再加回來

2 條路的終點是同一個專案,差別在你是從空的加上去,還是從完整的減下來,減法快,但刪的時候你還不知道哪些刪得掉,所以這篇才選加法,把每個相依進來的理由講一次,真的跟著做的時候用哪一個都行


下一篇

專案跑得起來,但現在唯一的測試是 Class.forName("io.ktor.server.engine.EmbeddedServer"),它只證明 version catalog 解析成功、jar 進了 classpath,GET / 回不回得了 Hello, Ktor!,到這裡為止是自己開一個視窗打 curl,用眼睛看的,下一篇用官方的 testApplication 把這件事變成測試,不啟動真的 server、不綁 port,請求直接進到 Ktor 的處理流程裡跑完


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 01 系列導讀
下一篇
Kotlin Ktor 實戰 101 Day 03 用 testApplication 寫下第一個測試
系列文
Kotlin Ktor 實戰 1017
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言